C#网络编程

WebSocket 聊天室

作者:陈广
日期:2018-12-27


学了 WebSocket 之后,自然是要拿来练练手,最合适的项目当然是做一个聊天室,比上一篇文章的聊天室更复杂一些,就按本系列文章中之前使用 Socket 实现的简易聊天室来实现吧。协议和它的基本一样,只有一个地方不同。《简易聊天室》这篇文章中的标志位使用了一个字节,而本文中的 WebSocket 版聊天室将使用2个字节来表示标志位。原因是如果还是使用 1 个字节, JavaScript 处理起来有些麻烦。

本以为很快搞定,硬是搞了一个星期,虽然只是业余时间搞搞,但也很杯具啊!很多时间花在了做界面上,只是想做一个让人看着不太恶心的界面而已,还是花掉这么多时间。万恶的 CSS!说多都是泪啊!C# 服务器代码拿之前的改改居然也能用,可能不太正规,管他呢,能聊天就行了。

正好有一个空服务器,聊天室已挂在网上,可以玩玩。什么时候关掉就不得而知了。

进入聊天室

创建项目

新建一个名为 WSChatRoom 的文件夹,在右键菜单上选择【Open with Code】打开此文件夹。按下【Ctrl + ~】快捷键打开终端,输入如下命令:

dotnet new empty

创建项目完成后,首先关掉 HTTPS,打开 Properties 文件夹下的 launchSettings.json 文件,将sslPort项的值更改为0以关闭 HTTPS。将applicationUrl项的值更改如下:

"applicationUrl": "http://localhost:5000",

编写中间件

在 WSChatRoom 项目下新建一个名为 Infrastructure 的文件夹,并在其中新建一个名为 WsHandleMiddleware.cs 的文件,输入如下代码:

using System;
using System.Text;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using System.Net.WebSockets;

namespace WSChatRoom.Infrastructure
{
    public class WsHandleMiddleware
    {   //用于存放在线用户信息
        public static ConcurrentDictionary<string, WebSocket> users = new ConcurrentDictionary<string, WebSocket>();
        private RequestDelegate nextDelegate;
        public WsHandleMiddleware(RequestDelegate next) => nextDelegate = next;

        public async Task Invoke(HttpContext context)
        {
            if (context.Request.Path == "/ws")
            {
                if (context.WebSockets.IsWebSocketRequest)
                {
                    WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync();
                    await WsHandle(context, webSocket);
                }
                else
                {
                    context.Response.StatusCode = 400;
                }
            }
            else
            {
                await nextDelegate.Invoke(context);
            }
        }

        private async Task WsHandle(HttpContext context, WebSocket webSocket)
        {
            byte[] buff = new byte[1024];
            string userName = "";
            WebSocketReceiveResult result = null;
            try
            {
                while (true)
                {
                    result = await webSocket.ReceiveAsync
                    (
                        buff,
                        CancellationToken.None
                    );
                    if (result.CloseStatus.HasValue || result.Count == 0)
                    {
                        break;
                    }

                    int count = result.Count;
                    if (buff[0] == 1)
                    {//用户请求加入聊天室                 
                        userName = Encoding.Unicode.GetString(buff, 2, count - 2); //解析出用户名
                        var userNameByte = Encoding.Unicode.GetBytes(userName + ":");
                        byte[] sendBuff;

                        //首先检查是否存在同名用户,如果存在则向请求者发送存在同名用户信息:255
                        if (users.ContainsKey(userName))
                        {
                            //存在同名,不再继续下面步骤                        
                            sendBuff = new byte[2];
                            sendBuff[0] = 255;
                            await webSocket.SendAsync(sendBuff, result.MessageType,
                                result.EndOfMessage, CancellationToken.None);
                            break;
                        }

                        //向所有在线用户发送新用户上线信息:2+用户名
                        byte[] sendByte = new byte[count];
                        Array.Copy(buff, sendByte, count);
                        sendByte[0] = 2;
                        SendAllUsers(sendByte);

                        //将新用户信息加入用户清单
                        users.TryAdd(userName, webSocket);

                        //向新用户发送在线用户名单列表
                        int index = 2; //指示当前压入字节的进度
                        sendBuff = new byte[2048];
                        sendBuff[0] = 1; //标志位设为 1
                                         //压入所有在线用户名,用户名之间用'\0'分隔
                        foreach (KeyValuePair<string, WebSocket> pair in users)
                        {
                            byte[] temp = Encoding.Unicode.GetBytes(pair.Key);//将用户名转化为字节数组
                            temp.CopyTo(sendBuff, index); //压入用户名
                            index += temp.Length;
                            //压入指示一个用户名结束的标志'\0'
                            sendBuff[index] = 0;
                            sendBuff[index + 1] = 0;
                            index += 2;
                        }

                        await webSocket.SendAsync(new ArraySegment<byte>(sendBuff, 0, index - 2),
                            result.MessageType, result.EndOfMessage, CancellationToken.None); //向客户端发送信息
                    }
                    else if (buff[0] == 2)//用户发来聊天信息,需要群发给所有在线用户。格式:3 + 用户名:+ 聊天信息
                    {
                        int index = 2; //指示当前压入字节的进度
                        byte[] userNameByte = Encoding.Unicode.GetBytes(userName + ":");
                        byte[] sendByte = new byte[count + userNameByte.Length];
                        sendByte[0] = 3; //将标志位设为 3                                                    
                        Array.Copy(userNameByte, 0, sendByte, index, userNameByte.Length); //压入用户名
                        index += userNameByte.Length;
                        Array.Copy(buff, 2, sendByte, index, count - 2); //拷贝用户发来的聊天信息
                        SendAllUsers(sendByte);
                    }
                }
                await webSocket.CloseAsync
                (
                    result.CloseStatus.Value,
                    result.CloseStatusDescription,
                    CancellationToken.None
                );
            }
            catch (Exception e)
            {
                 RemoveUser(userName, webSocket);
            }
        }
        //用于向所有用户群发消息
        static async Task SendAllUsers(byte[] sendByte)
        {
            foreach (KeyValuePair<string, WebSocket> pair in users)
            {   //可以快速完成的线程使用线程池
                WebSocket s = pair.Value;
                if (s.State != WebSocketState.Open)
                {
                    RemoveUser(pair.Key, s);
                    continue;
                }
                await s.SendAsync(sendByte, WebSocketMessageType.Binary, true, CancellationToken.None);
            }
        }

        //用于删除用户
        static void RemoveUser(string name, WebSocket s)
        {
            users.TryRemove(name, out s);

            //向在线用户群发退出信息:4 + 用户名
            byte[] nameByte = Encoding.Unicode.GetBytes(name);
            byte[] sendBuff = new byte[2 + nameByte.Length];
            sendBuff[0] = 4;
            nameByte.CopyTo(sendBuff, 2);
            SendAllUsers(sendBuff);
        }
    }
}

用户重名判断直接在控制器中判断了,所以这里没有相应代码

配置中间件

打开 Startup.cs 文件,将代码修改如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using WSChatRoom.Infrastructure;

namespace WSChatRoom
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseWebSockets(new WebSocketOptions()
            {
                ReceiveBufferSize = 2048
            });
            app.UseMiddleware<WsHandleMiddleware>();

            app.UseStaticFiles();
            app.UseMvcWithDefaultRoute();
        }
    }
}

加入控制器

在项目下新建一个 Controllers 文件夹,并在其中添加名为 HomeController.cs 的文件,输入如下代码:

using Microsoft.AspNetCore.Mvc;
using WSChatRoom.Infrastructure;

namespace WSChatRoom
{
    public class HomeController : Controller
    {
        public ViewResult Index() => View();

        [HttpPost]
        public IActionResult ChatRoom(string userName)
        {
            if (!WsHandleMiddleware.users.ContainsKey(userName))
            {
                TempData["usr"] = userName;
                return RedirectToAction(nameof(ChatRoom));
            }
            else
            {
                return View("Index","用户名已存在,请使用其它用户名!");
            }
        }

        public IActionResult ChatRoom() => View("ChatRoom", TempData["usr"]);
    }
}

这里需要注意的是两个控制器之间是不能通过成员变量来传递数据的,因为 MVC 会为每个请求创建一个新的控制器,所以这里使用了TempData来传递数据。

加入视图

首先在项目下新建一个 Views 文件夹,然后在其中新建一个名为 Home 的文件夹。

Index.cshtml

在 Home 文件夹下新建一个名为 Index.cshtml 的文件,输入如下代码:

@model string;

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>IOT小分队聊天登录</title>
    <link rel="stylesheet" href="/style.css">
</head>

<body>
    <div id="msg">@Model</div>
    <div id="container">
        <form id="nameForm" action="/Home/ChatRoom" method="POST" onsubmit="return CheckNull(this)">
            <input type="text" name="userName" id="userName" placeholder="请输入用户名">
            <button id="loginBtn">进入聊天室</button>
        </form>
    </div>
</body>
<script>
    function CheckNull(form) {
        if (form.userName.value == '') {
            alert("用户名不能为空!");
            return false;
        }
        return true;
    }
</script>

</html>

这个是登录界面

ChatRoom.cshtml

在 Home 文件夹下新建一个名为 ChatRoom.cshtml 的文件,输入如下代码:

@model string

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>IOT小分队聊天室</title>
    <link rel="stylesheet" href="/chatStyle.css">
</head>
<body>
    <div id="HolyGrail">
        <header>IOT小分队聊天室</header>
        <div id="chatContainer">
            <div id="msgDiv">
                <ul id="msgList"></ul>
            </div>
            <div id="userDiv">
                <ul id="userList"></ul>
            </div>
        </div>
        <footer>
            <textarea id="inputMsg"></textarea>
            <button id="sendBtn" onclick="sendMessage()">发送</button>
        </footer>
    </div>
</body>
<script>
    var userName='@Model';
</script>
<script src="/echo.js"></script>
</html>

这个页面是用来聊天的。

加入静态文件

下面是页面要使用到的 JavaScript 和 CSS 文件。在项目下新建一个 wwwroot 文件夹。

登录页面 CSS

在 wwwroot 文件夹下新建一个名为 style.css 的文件,输入代码如下:

/* ++++++++++++++++++++ Common Styles ++++++++++++++++++++ */

* {
	margin: 0px;
	padding: 0px;
}

body {
	color: #fff;
	background-color: #484848;
	font-family: 'verdana', 'helvetica', sans-serif;
	font-size: 100%;
	display: flex;
	justify-content: center;
	align-items: center;
	flex-direction: column;
}

input,
textarea,
select {
	font-family: 'verdana', 'helvetica', sans-serif;
	font-size: 13px;
}

/* ++++++++++++++++++++ Body Container ++++++++++++++++++++ */

#container {
	border: 8px solid #eee;
	border-radius: 20px;
	background-color: #6d6845;
	margin-top: 10px;
	display: flex;
	flex-direction: column;
}

#msg {
	color: red;
	margin-top: 10%;
	font-size: 30px;
}

#nameForm {
	display: flex;
	flex-direction: column;
}

#userName {
	padding: 5px 20px;
	margin: 30px 50px 15px 50px;
	font-size: 25px;
}

#loginBtn {
	margin: 15px 50px 30px 50px;
	padding: 5px 20px;
	font-size: 25px;
	background-color: #B3AB78;
	color: white;
	text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
	border: #fff 3px solid;
	border-radius: 20px;
	cursor: pointer;
}

聊天页面 CSS

在 wwwroot 文件夹下新建一个名为 chatStyle.css 的文件,输入代码如下:

/* ++++++++++++++++++++ Common Styles ++++++++++++++++++++ */

* {
	margin: 0px;
	padding: 0px;
}

body {
	color: #fff;
	background-color: #484848;
	font-family: 'verdana', 'helvetica', sans-serif;
	font-size: 100%;
}

ul,
ul li {
	list-style: none outside;
}

textarea,
	{
	font-family: 'verdana', 'helvetica', sans-serif;
	font-size: 13px;
}

/* +++++++++++++++++++ 聊天页面 ++++++++++++++++++++ */
#HolyGrail {
	width: 95%;
	height: 100%;
	margin: 0 auto;
	border: 8px solid #eee;
	border-radius: 20px;
	box-sizing: border-box;
}

header {
	height: 50px;
	background-color: #B3AB78;
	border-bottom: 6px solid #eee;
	font-size: 25px;
	padding-left: 20px;
}

#chatContainer {
	top: 50px;
	display: flex;
	flex-direction: row;
}

#msgDiv {
	flex: 7;
	height: 500px;
	background-color: #8E8257;
	overflow: auto
}

#msgList {
	padding-left: 10px;
	padding-top: 5px;
	font-size: 14px;
}

#userDiv {
	flex: 0 12em;
	height: 500px;
	background-color: #6D6845;
	border-left: 6px solid #eee;
	overflow: auto
}

#userList>li {
	border-bottom: #fff 1px solid;
	padding-left: 5px;
	padding-top: 5px;
	padding-bottom: 5px;
}

/* ++++++++++++++++++++ footer ++++++++++++++++++++ */

footer {
	display: flex;
	height: 60px;
	background-color: #514E33;
	border-top: 6px solid #eee;
}

#inputMsg {
	flex: 9;
	border: none;
	margin: 5px;
	border-radius: 0 0 0 10px;
	font-size: 20px;
}

#sendBtn {
	flex: 1;
	margin: 5px;
	margin-left: 0;
	font-size: 25px;
	background-color: #B3AB78;
	color: white;
	text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
	border: #fff 3px solid;
	border-radius: 0 0 10px 0;
	cursor: pointer;
}

加入 JavaScript

在 wwwroot 文件夹下新建一个名为 echo.js 的文件,输入代码如下:

var stateLabel = document.getElementById("stateLabel");
var connectBtn = document.getElementById('connect');
var disconnectBtn = document.getElementById('disconnect');
var sendBtn = document.getElementById('sendBtn');
var inputMsg = document.getElementById('inputMsg')
var consoleLog = document.getElementById('consoleLog');
var clearLogBtn = document.getElementById('clearLogBtn');
var msgList = document.getElementById('msgList');
var userList = document.getElementById('userList');
var socket;
//更新状态
function updateState() {
    function disable() {
        inputMsg.disabled = true;
        sendBtn.disabled = true;
    }

    function enable() {
        inputMsg.disabled = false;
        sendBtn.disabled = false;
    }
    connectBtn.disabled = true;
    if (!socket) {
        disable();
    } else {
        switch (socket.readyState) {
            case WebSocket.CLOSED:
                logText("关闭");
                disable();
                break;
            case WebSocket.CLOSING:
                logText("正在关闭...");
                disable();
                break;
            case WebSocket.CONNECTING:
                logText("正在连接...");
                disable();
                break;
            case WebSocket.OPEN:
                logText("正在打开...");
                enable();
                break;
            default:
                logText("Unknown WebSocket State: " + socket.readyState);
                disable();
                break;
        }
    }
}

//加载页面完成后开始连接服务器
window.onload = function () {
    DivSizeChange();
    logText("正在登录聊天室...");
    //如果需要在服务器上布署,请修改此URL
    socket = new WebSocket("ws://localhost:5000/ws");
    socket.onopen = function (event) {
        logText("成功连接服务器,申请加入...");
        socket.send(packMessage(1, userName));
    };

    socket.onclose = function (event) {
        updateState();
        logText('Connection closed. Code: ' + event.code + '. Reason: ' + event.reason);
    };

    socket.onerror = updateState;
    socket.binaryType = "arraybuffer";
    socket.onmessage = function (event) {
        var data = event.data;
        var flag = new Uint16Array(data, 0, 1);
        if (flag == 1) { //服务器向客户端发送聊天室所有成员列表,此时登录才算完全成功
            var str = String.fromCharCode.apply(null, new Uint16Array(data, 2));
            var names = str.split("\0");
            logText("成功加入聊天室")
            for (var i = 0; i < names.length; i++) {
                addUserToList(names[i]);
            }

        } else if (flag == 2) { //表示有新成员加入聊天室,将新成员信息传给客户端
            var usr = String.fromCharCode.apply(null, new Uint16Array(data, 2));
            logText("[" + usr + "] 加入聊天室");
            addUserToList(usr);
        } else if (flag == 3) { //服务器发送的是聊天信息
            var msg = String.fromCharCode.apply(null, new Uint16Array(data, 2));
            logMsg(msg);
        } else if (flag == 4) { //表示有成员退出聊天室,将退出成员信息传给客户端
            var usr = String.fromCharCode.apply(null, new Uint16Array(data, 2));
            //从用户列表删除下线用户
            var list = userList.getElementsByTagName("li");
            for (var i = 0; i < list.length; i++) {
                // alert(list[i].innerText);
                if (list[i].innerText == usr) {
                    userList.removeChild(list[i]);
                    break;
                }
            }
            logText("[" + usr + "] 退出聊天室");
        }
    };
}

//连接发送按钮的函数
function sendMessage() {
    if (!socket || socket.readyState !== WebSocket.OPEN) {
        alert("未连接服务器");
    }
    var data = inputMsg.value;
    socket.send(packMessage(2, data));
    inputMsg.value = "";
}
//在信息输入框中按下回车键发送消息
inputMsg.onkeydown = function (e) {
    if (e.keyCode == 13) {
        e.preventDefault();
        sendMessage();
    }
}

//写入系统消息
function logText(text) {
    var li = document.createElement("li");
    li.style.textAlign = "center";
    li.style.color = "#424200";
    li.innerHTML = text;
    addNodeToMsgList(li)
}
//写入聊天信息
function logMsg(text) {
    var li = document.createElement("li");
    li.innerHTML = text;
    addNodeToMsgList(li)
}

//在msgList中加入li
function addNodeToMsgList(li){
    //当信息框中信息超过50条,删除最前面一条
    while (msgList.childNodes.length > 50)
    {
        msgList.removeChild(msgList.firstChild);
    }
    msgList.appendChild(li);
    msgList.scrollTop = msgList.scrollHeight;
}

//加入一个新用户到用户列表
function addUserToList(usr) {
    li.innerHTML = usr;
    userList.appendChild(li);
    userDiv.scrollTop = userDiv.scrollHeight;
}
//打包协议,flag为标志位,str为发送内容
function packMessage(flag, str) {
    var buf = new ArrayBuffer(str.length * 2 + 2);
    var bufView = new Uint16Array(buf);
    bufView[0] = flag;
    for (var i = 0; i < str.length; i++) {
        bufView[i + 1] = str.charCodeAt(i);
    }
    return buf;
}

window.onresize = function () {
    DivSizeChange();
}

var HolyGrail = document.getElementById("HolyGrail");
var header = document.getElementsByTagName("header");
var footer = document.getElementsByTagName("footer");
var chatContainer = document.getElementById("chatContainer");
var msgDiv = document.getElementById("msgDiv");
var userDiv = document.getElementById("userDiv");

function DivSizeChange() {
    var windowHeight = window.innerHeight; //窗体高度
    var windowWidth = window.innerWidth; //窗体宽度

    chatContainer.style.height = windowHeight - 140 + "px"; //聊天用div高度
    msgDiv.style.height = chatContainer.offsetHeight + "px"; //聊天信息div 高度
    userDiv.style.height = chatContainer.offsetHeight + "px"; //用户列表div高度
}

运行程序

运行程序,效果如下图所示。

图 1: 运行程序

此程序相比 Window 版聊天程序有了小小进步,这次输入信息后直接按回车即可发送信息,显示信息超过50条后,再添加新信息会自动删除最早出现的信息。

;

© 2018 - IOT小分队文章发布系统 v0.3